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    // Non-static methods take the receiver as the first FFI parameter (the
728    // generated extern "C" fn signature is `fn (this: *const T, ...)`). Prepend
729    // an `IntPtr handle` here so the P/Invoke signature matches; without this
730    // the C# wrapper falls one argument short and the runtime throws
731    // EntryPointNotFoundException / the C# compiler rejects the call site.
732    let has_receiver = !method.is_static && method.receiver.is_some();
733
734    if !has_receiver && method.params.is_empty() {
735        out.push_str(");\n\n");
736    } else {
737        out.push('\n');
738        let total = if has_receiver {
739            method.params.len() + 1
740        } else {
741            method.params.len()
742        };
743        let mut idx = 0usize;
744        if has_receiver {
745            out.push_str("        IntPtr handle");
746            if total > 1 {
747                out.push(',');
748            }
749            out.push('\n');
750            idx += 1;
751        }
752        for param in method.params.iter() {
753            out.push_str("        ");
754            let pinvoke_ty = pinvoke_param_type(&param.ty);
755            if pinvoke_ty == "string" {
756                out.push_str("[MarshalAs(UnmanagedType.LPStr)] ");
757            }
758            let param_name = param.name.to_lower_camel_case();
759            out.push_str(&format!("{pinvoke_ty} {param_name}"));
760
761            if idx < total - 1 {
762                out.push(',');
763            }
764            out.push('\n');
765            idx += 1;
766        }
767        out.push_str("    );\n\n");
768    }
769
770    out
771}
772
773fn gen_exception_class(namespace: &str, class_name: &str) -> String {
774    let mut out = csharp_file_header();
775    out.push_str("using System;\n\n");
776
777    out.push_str(&format!("namespace {};\n\n", namespace));
778
779    out.push_str(&format!("public class {} : Exception\n", class_name));
780    out.push_str("{\n");
781    out.push_str("    public int Code { get; }\n\n");
782    out.push_str(&format!(
783        "    public {}(int code, string message) : base(message)\n",
784        class_name
785    ));
786    out.push_str("    {\n");
787    out.push_str("        Code = code;\n");
788    out.push_str("    }\n");
789    out.push_str("}\n");
790
791    out
792}
793
794#[allow(clippy::too_many_arguments)]
795fn gen_wrapper_class(
796    api: &ApiSurface,
797    namespace: &str,
798    class_name: &str,
799    exception_name: &str,
800    prefix: &str,
801    bridge_param_names: &HashSet<String>,
802    bridge_type_aliases: &HashSet<String>,
803    has_visitor_callbacks: bool,
804    streaming_methods: &HashSet<String>,
805) -> String {
806    let mut out = csharp_file_header();
807    out.push_str("using System;\n");
808    out.push_str("using System.Collections.Generic;\n");
809    out.push_str("using System.Runtime.InteropServices;\n");
810    out.push_str("using System.Text.Json;\n");
811    out.push_str("using System.Text.Json.Serialization;\n");
812    out.push_str("using System.Threading.Tasks;\n\n");
813
814    out.push_str(&format!("namespace {};\n\n", namespace));
815
816    out.push_str(&format!("public static class {}\n", class_name));
817    out.push_str("{\n");
818    out.push_str("    private static readonly JsonSerializerOptions JsonOptions = new()\n");
819    out.push_str("    {\n");
820    out.push_str("        Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) },\n");
821    out.push_str("        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault\n");
822    out.push_str("    };\n\n");
823
824    // Enum names: used to distinguish opaque struct handles from enum return types.
825    let enum_names: HashSet<String> = api.enums.iter().map(|e| e.name.to_pascal_case()).collect();
826
827    // Truly opaque types (is_opaque = true) — returned/passed as handles, no JSON serialization.
828    let true_opaque_types: HashSet<String> = api
829        .types
830        .iter()
831        .filter(|t| t.is_opaque)
832        .map(|t| t.name.clone())
833        .collect();
834
835    // Generate wrapper methods for functions
836    for func in &api.functions {
837        out.push_str(&gen_wrapper_function(
838            func,
839            exception_name,
840            prefix,
841            &enum_names,
842            &true_opaque_types,
843            bridge_param_names,
844            bridge_type_aliases,
845        ));
846    }
847
848    // Generate wrapper methods for type methods (prefixed with type name to avoid collisions).
849    // Skip streaming adapter methods — their FFI signature uses callbacks that P/Invoke can't call.
850    for typ in api.types.iter().filter(|typ| !typ.is_trait) {
851        // Skip opaque types — their methods belong on the opaque handle class, not the static wrapper
852        if typ.is_opaque {
853            continue;
854        }
855        for method in &typ.methods {
856            if streaming_methods.contains(&method.name) {
857                continue;
858            }
859            out.push_str(&gen_wrapper_method(
860                method,
861                exception_name,
862                prefix,
863                &typ.name,
864                &enum_names,
865                &true_opaque_types,
866                bridge_param_names,
867                bridge_type_aliases,
868            ));
869        }
870    }
871
872    // Inject ConvertWithVisitor when a visitor bridge is configured.
873    if has_visitor_callbacks {
874        out.push_str(&crate::gen_visitor::gen_convert_with_visitor_method(
875            exception_name,
876            prefix,
877        ));
878    }
879
880    // Add error handling helper
881    out.push_str("    private static ");
882    out.push_str(&format!("{} GetLastError()\n", exception_name));
883    out.push_str("    {\n");
884    out.push_str("        var code = NativeMethods.LastErrorCode();\n");
885    out.push_str("        var ctxPtr = NativeMethods.LastErrorContext();\n");
886    out.push_str("        var message = Marshal.PtrToStringAnsi(ctxPtr) ?? \"Unknown error\";\n");
887    out.push_str(&format!("        return new {}(code, message);\n", exception_name));
888    out.push_str("    }\n");
889
890    out.push_str("}\n");
891
892    out
893}
894
895// ---------------------------------------------------------------------------
896// Helpers: Named-param setup/teardown for opaque handle marshalling
897// ---------------------------------------------------------------------------
898
899/// For each `Named` parameter, emit code to serialise it to JSON and obtain a native handle.
900///
901/// For truly opaque types (is_opaque = true), the C# class already wraps the native handle, so
902/// we pass `param.Handle` directly without any JSON serialisation.
903///
904/// ```text
905/// // Data struct (has from_json):
906/// var optionsJson = JsonSerializer.Serialize(options);
907/// var optionsHandle = NativeMethods.ConversionOptionsFromJson(optionsJson);
908///
909/// // Truly opaque handle: passed as engineHandle.Handle directly — no setup needed.
910/// ```
911fn emit_named_param_setup(
912    out: &mut String,
913    params: &[alef_core::ir::ParamDef],
914    indent: &str,
915    true_opaque_types: &HashSet<String>,
916) {
917    for param in params {
918        let param_name = param.name.to_lower_camel_case();
919        let json_var = format!("{param_name}Json");
920        let handle_var = format!("{param_name}Handle");
921
922        match &param.ty {
923            TypeRef::Named(type_name) => {
924                // Truly opaque handles: the C# wrapper class holds the IntPtr directly.
925                // No from_json round-trip needed — pass .Handle directly in native_call_arg.
926                if true_opaque_types.contains(type_name) {
927                    continue;
928                }
929                let from_json_method = format!("{}FromJson", type_name.to_pascal_case());
930                if param.optional {
931                    out.push_str(&format!(
932                        "{indent}var {json_var} = {param_name} != null ? JsonSerializer.Serialize({param_name}, JsonOptions) : \"null\";\n"
933                    ));
934                } else {
935                    out.push_str(&format!(
936                        "{indent}var {json_var} = JsonSerializer.Serialize({param_name}, JsonOptions);\n"
937                    ));
938                }
939                out.push_str(&format!(
940                    "{indent}var {handle_var} = NativeMethods.{from_json_method}({json_var});\n"
941                ));
942            }
943            TypeRef::Vec(_) | TypeRef::Map(_, _) => {
944                // Vec/Map: serialize to JSON string, marshal to native pointer
945                out.push_str(&format!(
946                    "{indent}var {json_var} = JsonSerializer.Serialize({param_name}, JsonOptions);\n"
947                ));
948                out.push_str(&format!(
949                    "{indent}var {handle_var} = Marshal.StringToHGlobalAnsi({json_var});\n"
950                ));
951            }
952            TypeRef::Bytes => {
953                // byte[]: pin the managed array and pass pointer to native
954                out.push_str(&format!(
955                    "{indent}var {handle_var} = GCHandle.Alloc({param_name}, GCHandleType.Pinned);\n"
956                ));
957            }
958            _ => {}
959        }
960    }
961}
962
963/// Returns true if the FFI return type is a pointer (IntPtr), as opposed to a numeric value.
964/// Only pointer-returning functions use `IntPtr.Zero` as an error sentinel.
965fn returns_ptr(ty: &TypeRef) -> bool {
966    matches!(
967        ty,
968        TypeRef::String
969            | TypeRef::Char
970            | TypeRef::Path
971            | TypeRef::Json
972            | TypeRef::Named(_)
973            | TypeRef::Vec(_)
974            | TypeRef::Map(_, _)
975            | TypeRef::Bytes
976            | TypeRef::Optional(_)
977    )
978}
979
980/// Returns the argument expression to pass to the native method for a given parameter.
981///
982/// For truly opaque types (is_opaque = true), the C# class wraps an IntPtr; pass `.Handle`.
983/// For data-struct `Named` types this is the handle variable (e.g. `optionsHandle`).
984/// For everything else it is the parameter name (with `!` for optional).
985fn native_call_arg(ty: &TypeRef, param_name: &str, optional: bool, true_opaque_types: &HashSet<String>) -> String {
986    match ty {
987        TypeRef::Named(type_name) if true_opaque_types.contains(type_name) => {
988            // Truly opaque: unwrap the IntPtr from the C# handle class.
989            let bang = if optional { "!" } else { "" };
990            format!("{param_name}{bang}.Handle")
991        }
992        TypeRef::Named(_) | TypeRef::Vec(_) | TypeRef::Map(_, _) => {
993            format!("{param_name}Handle")
994        }
995        TypeRef::Bytes => {
996            format!("{param_name}Handle.AddrOfPinnedObject()")
997        }
998        TypeRef::Primitive(alef_core::ir::PrimitiveType::Bool) => {
999            // FFI convention: bool marshalled as int (0 = false, non-zero = true)
1000            if optional {
1001                format!("({param_name}?.Value ? 1 : 0)")
1002            } else {
1003                format!("({param_name} ? 1 : 0)")
1004            }
1005        }
1006        ty => {
1007            if optional {
1008                // For optional primitive types (e.g. ulong?, uint?), use GetValueOrDefault()
1009                // to safely unwrap with a default of 0 if null. String/Char/Path/Json are
1010                // reference types so `!` is correct for those.
1011                let needs_value_unwrap = matches!(ty, TypeRef::Primitive(_) | TypeRef::Duration);
1012                if needs_value_unwrap {
1013                    format!("{param_name}.GetValueOrDefault()")
1014                } else {
1015                    format!("{param_name}!")
1016                }
1017            } else {
1018                param_name.to_string()
1019            }
1020        }
1021    }
1022}
1023
1024/// Emit cleanup code to free native handles allocated for `Named` parameters.
1025///
1026/// Truly opaque handles (is_opaque = true) are NOT freed here — their lifetime is managed by
1027/// the C# wrapper class (IDisposable). Only data-struct handles (from_json-allocated) are freed.
1028fn emit_named_param_teardown(
1029    out: &mut String,
1030    params: &[alef_core::ir::ParamDef],
1031    true_opaque_types: &HashSet<String>,
1032) {
1033    for param in params {
1034        let param_name = param.name.to_lower_camel_case();
1035        let handle_var = format!("{param_name}Handle");
1036        match &param.ty {
1037            TypeRef::Named(type_name) => {
1038                if true_opaque_types.contains(type_name) {
1039                    // Caller owns the opaque handle — do not free it here.
1040                    continue;
1041                }
1042                let free_method = format!("{}Free", type_name.to_pascal_case());
1043                out.push_str(&format!("        NativeMethods.{free_method}({handle_var});\n"));
1044            }
1045            TypeRef::Vec(_) | TypeRef::Map(_, _) => {
1046                out.push_str(&format!("        Marshal.FreeHGlobal({handle_var});\n"));
1047            }
1048            TypeRef::Bytes => {
1049                out.push_str(&format!("        {handle_var}.Free();\n"));
1050            }
1051            _ => {}
1052        }
1053    }
1054}
1055
1056/// Emit cleanup code with configurable indentation (used inside `Task.Run` lambdas).
1057fn emit_named_param_teardown_indented(
1058    out: &mut String,
1059    params: &[alef_core::ir::ParamDef],
1060    indent: &str,
1061    true_opaque_types: &HashSet<String>,
1062) {
1063    for param in params {
1064        let param_name = param.name.to_lower_camel_case();
1065        let handle_var = format!("{param_name}Handle");
1066        match &param.ty {
1067            TypeRef::Named(type_name) => {
1068                if true_opaque_types.contains(type_name) {
1069                    // Caller owns the opaque handle — do not free it here.
1070                    continue;
1071                }
1072                let free_method = format!("{}Free", type_name.to_pascal_case());
1073                out.push_str(&format!("{indent}NativeMethods.{free_method}({handle_var});\n"));
1074            }
1075            TypeRef::Vec(_) | TypeRef::Map(_, _) => {
1076                out.push_str(&format!("{indent}Marshal.FreeHGlobal({handle_var});\n"));
1077            }
1078            TypeRef::Bytes => {
1079                out.push_str(&format!("{indent}{handle_var}.Free();\n"));
1080            }
1081            _ => {}
1082        }
1083    }
1084}
1085
1086fn gen_wrapper_function(
1087    func: &FunctionDef,
1088    _exception_name: &str,
1089    _prefix: &str,
1090    enum_names: &HashSet<String>,
1091    true_opaque_types: &HashSet<String>,
1092    bridge_param_names: &HashSet<String>,
1093    bridge_type_aliases: &HashSet<String>,
1094) -> String {
1095    let mut out = String::with_capacity(1024);
1096
1097    // Collect visible params (non-bridge) for the public C# signature.
1098    let visible_params: Vec<alef_core::ir::ParamDef> = func
1099        .params
1100        .iter()
1101        .filter(|p| !is_bridge_param(p, bridge_param_names, bridge_type_aliases))
1102        .cloned()
1103        .collect();
1104
1105    // XML doc comment using shared doc emission
1106    doc_emission::emit_csharp_doc(&mut out, &func.doc, "    ");
1107    for param in &visible_params {
1108        if !func.doc.is_empty() {
1109            out.push_str(&format!(
1110                "    /// <param name=\"{}\">{}</param>\n",
1111                param.name.to_lower_camel_case(),
1112                if param.optional { "Optional." } else { "" }
1113            ));
1114        }
1115    }
1116
1117    out.push_str("    public static ");
1118
1119    // Return type — use async Task<T> for async methods
1120    if func.is_async {
1121        if func.return_type == TypeRef::Unit {
1122            out.push_str("async Task");
1123        } else {
1124            out.push_str(&format!("async Task<{}>", csharp_type(&func.return_type)));
1125        }
1126    } else if func.return_type == TypeRef::Unit {
1127        out.push_str("void");
1128    } else {
1129        out.push_str(&csharp_type(&func.return_type));
1130    }
1131
1132    out.push_str(&format!(" {}", to_csharp_name(&func.name)));
1133    out.push('(');
1134
1135    // Parameters (bridge params stripped from public signature)
1136    for (i, param) in visible_params.iter().enumerate() {
1137        let param_name = param.name.to_lower_camel_case();
1138        let mapped = csharp_type(&param.ty);
1139        if param.optional && !mapped.ends_with('?') {
1140            out.push_str(&format!("{mapped}? {param_name}"));
1141        } else {
1142            out.push_str(&format!("{mapped} {param_name}"));
1143        }
1144
1145        if i < visible_params.len() - 1 {
1146            out.push_str(", ");
1147        }
1148    }
1149
1150    out.push_str(")\n    {\n");
1151
1152    // Null checks for required string/object parameters
1153    for param in &visible_params {
1154        if !param.optional && matches!(param.ty, TypeRef::String | TypeRef::Named(_) | TypeRef::Bytes) {
1155            let param_name = param.name.to_lower_camel_case();
1156            out.push_str(&format!("        ArgumentNullException.ThrowIfNull({param_name});\n"));
1157        }
1158    }
1159
1160    // Serialize Named (opaque handle) params to JSON and obtain native handles.
1161    emit_named_param_setup(&mut out, &visible_params, "        ", true_opaque_types);
1162
1163    // Method body - delegation to native method with proper marshalling
1164    let cs_native_name = to_csharp_name(&func.name);
1165
1166    if func.is_async {
1167        // Async: wrap in Task.Run for non-blocking execution. CS1997 disallows
1168        // `return await Task.Run(...)` in an `async Task` (non-generic) method,
1169        // so for unit returns we drop the `return`.
1170        if func.return_type == TypeRef::Unit {
1171            out.push_str("        await Task.Run(() =>\n        {\n");
1172        } else {
1173            out.push_str("        return await Task.Run(() =>\n        {\n");
1174        }
1175
1176        if func.return_type != TypeRef::Unit {
1177            out.push_str("            var nativeResult = ");
1178        } else {
1179            out.push_str("            ");
1180        }
1181
1182        out.push_str(&format!("NativeMethods.{}(", cs_native_name));
1183
1184        if visible_params.is_empty() {
1185            out.push_str(");\n");
1186        } else {
1187            out.push('\n');
1188            for (i, param) in visible_params.iter().enumerate() {
1189                let param_name = param.name.to_lower_camel_case();
1190                let arg = native_call_arg(&param.ty, &param_name, param.optional, true_opaque_types);
1191                out.push_str(&format!("                {arg}"));
1192                if i < visible_params.len() - 1 {
1193                    out.push(',');
1194                }
1195                out.push('\n');
1196            }
1197            out.push_str("            );\n");
1198        }
1199
1200        // Check for FFI error (null result means the call failed).
1201        if func.return_type != TypeRef::Unit {
1202            out.push_str(
1203                "            if (nativeResult == IntPtr.Zero)\n            {\n                var err = GetLastError();\n                if (err.Code != 0)\n                {\n                    throw err;\n                }\n            }\n",
1204            );
1205        }
1206
1207        emit_return_marshalling_indented(
1208            &mut out,
1209            &func.return_type,
1210            "            ",
1211            enum_names,
1212            true_opaque_types,
1213        );
1214        emit_named_param_teardown_indented(&mut out, &visible_params, "            ", true_opaque_types);
1215        emit_return_statement_indented(&mut out, &func.return_type, "            ");
1216        out.push_str("        });\n");
1217    } else {
1218        if func.return_type != TypeRef::Unit {
1219            out.push_str("        var nativeResult = ");
1220        } else {
1221            out.push_str("        ");
1222        }
1223
1224        out.push_str(&format!("NativeMethods.{}(", cs_native_name));
1225
1226        if visible_params.is_empty() {
1227            out.push_str(");\n");
1228        } else {
1229            out.push('\n');
1230            for (i, param) in visible_params.iter().enumerate() {
1231                let param_name = param.name.to_lower_camel_case();
1232                let arg = native_call_arg(&param.ty, &param_name, param.optional, true_opaque_types);
1233                out.push_str(&format!("            {arg}"));
1234                if i < visible_params.len() - 1 {
1235                    out.push(',');
1236                }
1237                out.push('\n');
1238            }
1239            out.push_str("        );\n");
1240        }
1241
1242        // Check for FFI error (null result means the call failed).
1243        // Only emit for pointer-returning functions — numeric returns (ulong, uint, bool)
1244        // don't use IntPtr.Zero as an error sentinel.
1245        if func.return_type != TypeRef::Unit && returns_ptr(&func.return_type) {
1246            out.push_str(
1247                "        if (nativeResult == IntPtr.Zero)\n        {\n            var err = GetLastError();\n            if (err.Code != 0)\n            {\n                throw err;\n            }\n        }\n",
1248            );
1249        }
1250
1251        emit_return_marshalling(&mut out, &func.return_type, enum_names, true_opaque_types);
1252        emit_named_param_teardown(&mut out, &visible_params, true_opaque_types);
1253        emit_return_statement(&mut out, &func.return_type);
1254    }
1255
1256    out.push_str("    }\n\n");
1257
1258    out
1259}
1260
1261#[allow(clippy::too_many_arguments)]
1262fn gen_wrapper_method(
1263    method: &MethodDef,
1264    _exception_name: &str,
1265    _prefix: &str,
1266    type_name: &str,
1267    enum_names: &HashSet<String>,
1268    true_opaque_types: &HashSet<String>,
1269    bridge_param_names: &HashSet<String>,
1270    bridge_type_aliases: &HashSet<String>,
1271) -> String {
1272    let mut out = String::with_capacity(1024);
1273
1274    // Collect visible params (non-bridge) for the public C# signature.
1275    let visible_params: Vec<alef_core::ir::ParamDef> = method
1276        .params
1277        .iter()
1278        .filter(|p| !is_bridge_param(p, bridge_param_names, bridge_type_aliases))
1279        .cloned()
1280        .collect();
1281
1282    // XML doc comment using shared doc emission
1283    doc_emission::emit_csharp_doc(&mut out, &method.doc, "    ");
1284    for param in &visible_params {
1285        if !method.doc.is_empty() {
1286            out.push_str(&format!(
1287                "    /// <param name=\"{}\">{}</param>\n",
1288                param.name.to_lower_camel_case(),
1289                if param.optional { "Optional." } else { "" }
1290            ));
1291        }
1292    }
1293
1294    // The wrapper class is always `static class`, so all methods must be static.
1295    out.push_str("    public static ");
1296
1297    // Return type — use async Task<T> for async methods
1298    if method.is_async {
1299        if method.return_type == TypeRef::Unit {
1300            out.push_str("async Task");
1301        } else {
1302            out.push_str(&format!("async Task<{}>", csharp_type(&method.return_type)));
1303        }
1304    } else if method.return_type == TypeRef::Unit {
1305        out.push_str("void");
1306    } else {
1307        out.push_str(&csharp_type(&method.return_type));
1308    }
1309
1310    // Prefix method name with type name to avoid collisions (e.g., MetadataConfigDefault)
1311    let method_cs_name = format!("{}{}", type_name, to_csharp_name(&method.name));
1312    out.push_str(&format!(" {method_cs_name}"));
1313    out.push('(');
1314
1315    // Non-static methods need a `handle` parameter that the wrapper threads to
1316    // the native receiver. Without this, the public method has no way to refer
1317    // to the instance and calls NativeMethods.{Method}() one argument short.
1318    let has_receiver = !method.is_static && method.receiver.is_some();
1319    if has_receiver {
1320        out.push_str("IntPtr handle");
1321        if !visible_params.is_empty() {
1322            out.push_str(", ");
1323        }
1324    }
1325
1326    // Parameters (bridge params stripped from public signature)
1327    for (i, param) in visible_params.iter().enumerate() {
1328        let param_name = param.name.to_lower_camel_case();
1329        let mapped = csharp_type(&param.ty);
1330        if param.optional && !mapped.ends_with('?') {
1331            out.push_str(&format!("{mapped}? {param_name}"));
1332        } else {
1333            out.push_str(&format!("{mapped} {param_name}"));
1334        }
1335
1336        if i < visible_params.len() - 1 {
1337            out.push_str(", ");
1338        }
1339    }
1340
1341    out.push_str(")\n    {\n");
1342
1343    // Null checks for required string/object parameters
1344    for param in &visible_params {
1345        if !param.optional && matches!(param.ty, TypeRef::String | TypeRef::Named(_) | TypeRef::Bytes) {
1346            let param_name = param.name.to_lower_camel_case();
1347            out.push_str(&format!("        ArgumentNullException.ThrowIfNull({param_name});\n"));
1348        }
1349    }
1350
1351    // Serialize Named (opaque handle) params to JSON and obtain native handles.
1352    emit_named_param_setup(&mut out, &visible_params, "        ", true_opaque_types);
1353
1354    // Method body - delegation to native method with proper marshalling.
1355    // Use the type-prefixed name to match the P/Invoke declaration, which includes the type
1356    // name to avoid collisions between different types with identically-named methods
1357    // (e.g. BrowserConfig::default and CrawlConfig::default).
1358    let cs_native_name = format!("{}{}", type_name.to_pascal_case(), to_csharp_name(&method.name));
1359
1360    if method.is_async {
1361        // Async: wrap in Task.Run. For unit returns drop the `return` so CS1997 (async Task
1362        // method can't `return await` of non-generic Task) does not fire.
1363        if method.return_type == TypeRef::Unit {
1364            out.push_str("        await Task.Run(() =>\n        {\n");
1365        } else {
1366            out.push_str("        return await Task.Run(() =>\n        {\n");
1367        }
1368
1369        if method.return_type != TypeRef::Unit {
1370            out.push_str("            var nativeResult = ");
1371        } else {
1372            out.push_str("            ");
1373        }
1374
1375        out.push_str(&format!("NativeMethods.{}(", cs_native_name));
1376
1377        if !has_receiver && visible_params.is_empty() {
1378            out.push_str(");\n");
1379        } else {
1380            out.push('\n');
1381            let total = if has_receiver {
1382                visible_params.len() + 1
1383            } else {
1384                visible_params.len()
1385            };
1386            let mut idx = 0usize;
1387            if has_receiver {
1388                out.push_str("                handle");
1389                if total > 1 {
1390                    out.push(',');
1391                }
1392                out.push('\n');
1393                idx += 1;
1394            }
1395            for param in visible_params.iter() {
1396                let param_name = param.name.to_lower_camel_case();
1397                let arg = native_call_arg(&param.ty, &param_name, param.optional, true_opaque_types);
1398                out.push_str(&format!("                {arg}"));
1399                if idx < total - 1 {
1400                    out.push(',');
1401                }
1402                out.push('\n');
1403                idx += 1;
1404            }
1405            out.push_str("            );\n");
1406        }
1407
1408        emit_return_marshalling_indented(
1409            &mut out,
1410            &method.return_type,
1411            "            ",
1412            enum_names,
1413            true_opaque_types,
1414        );
1415        emit_named_param_teardown_indented(&mut out, &visible_params, "            ", true_opaque_types);
1416        emit_return_statement_indented(&mut out, &method.return_type, "            ");
1417        out.push_str("        });\n");
1418    } else {
1419        if method.return_type != TypeRef::Unit {
1420            out.push_str("        var nativeResult = ");
1421        } else {
1422            out.push_str("        ");
1423        }
1424
1425        out.push_str(&format!("NativeMethods.{}(", cs_native_name));
1426
1427        if !has_receiver && visible_params.is_empty() {
1428            out.push_str(");\n");
1429        } else {
1430            out.push('\n');
1431            let total = if has_receiver {
1432                visible_params.len() + 1
1433            } else {
1434                visible_params.len()
1435            };
1436            let mut idx = 0usize;
1437            if has_receiver {
1438                out.push_str("            handle");
1439                if total > 1 {
1440                    out.push(',');
1441                }
1442                out.push('\n');
1443                idx += 1;
1444            }
1445            for param in visible_params.iter() {
1446                let param_name = param.name.to_lower_camel_case();
1447                let arg = native_call_arg(&param.ty, &param_name, param.optional, true_opaque_types);
1448                out.push_str(&format!("            {arg}"));
1449                if idx < total - 1 {
1450                    out.push(',');
1451                }
1452                out.push('\n');
1453                idx += 1;
1454            }
1455            out.push_str("        );\n");
1456        }
1457
1458        emit_return_marshalling(&mut out, &method.return_type, enum_names, true_opaque_types);
1459        emit_named_param_teardown(&mut out, &visible_params, true_opaque_types);
1460        emit_return_statement(&mut out, &method.return_type);
1461    }
1462
1463    out.push_str("    }\n\n");
1464
1465    out
1466}
1467
1468/// Emit the return-value marshalling code shared by both function and method wrappers.
1469///
1470/// This function emits the code to convert the raw P/Invoke `result` into the managed return
1471/// type and store it in a local variable `returnValue`.  It intentionally does **not** emit
1472/// the `return` statement so that callers can interpose cleanup (param handle teardown) between
1473/// the value computation and the return.
1474///
1475/// `enum_names`: the set of C# type names that are enums (not opaque handles).
1476/// `true_opaque_types`: types with `is_opaque = true` — wrapped in `new CsType(result)`.
1477///
1478/// Callers must invoke `emit_return_statement` after their cleanup to complete the method body.
1479fn emit_return_marshalling(
1480    out: &mut String,
1481    return_type: &TypeRef,
1482    enum_names: &HashSet<String>,
1483    true_opaque_types: &HashSet<String>,
1484) {
1485    if *return_type == TypeRef::Unit {
1486        // void — nothing to return
1487        return;
1488    }
1489
1490    if returns_string(return_type) {
1491        // IntPtr → string, then free the native buffer.
1492        out.push_str("        var returnValue = Marshal.PtrToStringUTF8(nativeResult) ?? string.Empty;\n");
1493        out.push_str("        NativeMethods.FreeString(nativeResult);\n");
1494    } else if returns_bool_via_int(return_type) {
1495        // C int → bool
1496        out.push_str("        var returnValue = nativeResult != 0;\n");
1497    } else if let TypeRef::Named(type_name) = return_type {
1498        let pascal = type_name.to_pascal_case();
1499        if true_opaque_types.contains(type_name) {
1500            // Truly opaque handle: wrap the IntPtr in the C# handle class.
1501            out.push_str(&format!("        var returnValue = new {pascal}(nativeResult);\n"));
1502        } else if !enum_names.contains(&pascal) {
1503            // Data struct with to_json: call to_json, deserialise, then free both.
1504            let to_json_method = format!("{pascal}ToJson");
1505            let free_method = format!("{pascal}Free");
1506            let cs_ty = csharp_type(return_type);
1507            out.push_str(&format!(
1508                "        var jsonPtr = NativeMethods.{to_json_method}(nativeResult);\n"
1509            ));
1510            out.push_str("        var json = Marshal.PtrToStringUTF8(jsonPtr);\n");
1511            out.push_str("        NativeMethods.FreeString(jsonPtr);\n");
1512            out.push_str(&format!("        NativeMethods.{free_method}(nativeResult);\n"));
1513            out.push_str(&format!(
1514                "        var returnValue = JsonSerializer.Deserialize<{}>(json ?? \"null\", JsonOptions)!;\n",
1515                cs_ty
1516            ));
1517        } else {
1518            // Enum returned as JSON string IntPtr.
1519            let cs_ty = csharp_type(return_type);
1520            out.push_str("        var json = Marshal.PtrToStringUTF8(nativeResult);\n");
1521            out.push_str("        NativeMethods.FreeString(nativeResult);\n");
1522            out.push_str(&format!(
1523                "        var returnValue = JsonSerializer.Deserialize<{}>(json ?? \"null\", JsonOptions)!;\n",
1524                cs_ty
1525            ));
1526        }
1527    } else if returns_json_object(return_type) {
1528        // IntPtr → JSON string → deserialized object, then free the native buffer.
1529        let cs_ty = csharp_type(return_type);
1530        out.push_str("        var json = Marshal.PtrToStringUTF8(nativeResult);\n");
1531        out.push_str("        NativeMethods.FreeString(nativeResult);\n");
1532        out.push_str(&format!(
1533            "        var returnValue = JsonSerializer.Deserialize<{}>(json ?? \"null\", JsonOptions)!;\n",
1534            cs_ty
1535        ));
1536    } else {
1537        // Numeric primitives — direct return.
1538        out.push_str("        var returnValue = nativeResult;\n");
1539    }
1540}
1541
1542/// Emit the final `return returnValue;` statement after cleanup.
1543fn emit_return_statement(out: &mut String, return_type: &TypeRef) {
1544    if *return_type != TypeRef::Unit {
1545        out.push_str("        return returnValue;\n");
1546    }
1547}
1548
1549/// Emit the return-value marshalling code with configurable indentation.
1550///
1551/// Like `emit_return_marshalling` this stores the value in `returnValue` without emitting
1552/// the final `return` statement.  Callers must call `emit_return_statement_indented` after.
1553fn emit_return_marshalling_indented(
1554    out: &mut String,
1555    return_type: &TypeRef,
1556    indent: &str,
1557    enum_names: &HashSet<String>,
1558    true_opaque_types: &HashSet<String>,
1559) {
1560    if *return_type == TypeRef::Unit {
1561        return;
1562    }
1563
1564    if returns_string(return_type) {
1565        out.push_str(&format!(
1566            "{indent}var returnValue = Marshal.PtrToStringUTF8(nativeResult) ?? string.Empty;\n"
1567        ));
1568        out.push_str(&format!("{indent}NativeMethods.FreeString(nativeResult);\n"));
1569    } else if returns_bool_via_int(return_type) {
1570        out.push_str(&format!("{indent}var returnValue = nativeResult != 0;\n"));
1571    } else if let TypeRef::Named(type_name) = return_type {
1572        let pascal = type_name.to_pascal_case();
1573        if true_opaque_types.contains(type_name) {
1574            // Truly opaque handle: wrap the IntPtr in the C# handle class.
1575            out.push_str(&format!("{indent}var returnValue = new {pascal}(nativeResult);\n"));
1576        } else if !enum_names.contains(&pascal) {
1577            // Data struct with to_json: call to_json, deserialise, then free both.
1578            let to_json_method = format!("{pascal}ToJson");
1579            let free_method = format!("{pascal}Free");
1580            let cs_ty = csharp_type(return_type);
1581            out.push_str(&format!(
1582                "{indent}var jsonPtr = NativeMethods.{to_json_method}(nativeResult);\n"
1583            ));
1584            out.push_str(&format!("{indent}var json = Marshal.PtrToStringUTF8(jsonPtr);\n"));
1585            out.push_str(&format!("{indent}NativeMethods.FreeString(jsonPtr);\n"));
1586            out.push_str(&format!("{indent}NativeMethods.{free_method}(nativeResult);\n"));
1587            out.push_str(&format!(
1588                "{indent}var returnValue = JsonSerializer.Deserialize<{}>(json ?? \"null\", JsonOptions)!;\n",
1589                cs_ty
1590            ));
1591        } else {
1592            // Enum returned as JSON string IntPtr.
1593            let cs_ty = csharp_type(return_type);
1594            out.push_str(&format!("{indent}var json = Marshal.PtrToStringUTF8(nativeResult);\n"));
1595            out.push_str(&format!("{indent}NativeMethods.FreeString(nativeResult);\n"));
1596            out.push_str(&format!(
1597                "{indent}var returnValue = JsonSerializer.Deserialize<{}>(json ?? \"null\", JsonOptions)!;\n",
1598                cs_ty
1599            ));
1600        }
1601    } else if returns_json_object(return_type) {
1602        let cs_ty = csharp_type(return_type);
1603        out.push_str(&format!("{indent}var json = Marshal.PtrToStringUTF8(nativeResult);\n"));
1604        out.push_str(&format!("{indent}NativeMethods.FreeString(nativeResult);\n"));
1605        out.push_str(&format!(
1606            "{indent}var returnValue = JsonSerializer.Deserialize<{}>(json ?? \"null\", JsonOptions)!;\n",
1607            cs_ty
1608        ));
1609    } else {
1610        out.push_str(&format!("{indent}var returnValue = nativeResult;\n"));
1611    }
1612}
1613
1614/// Emit the final `return returnValue;` with configurable indentation.
1615fn emit_return_statement_indented(out: &mut String, return_type: &TypeRef, indent: &str) {
1616    if *return_type != TypeRef::Unit {
1617        out.push_str(&format!("{indent}return returnValue;\n"));
1618    }
1619}
1620
1621fn gen_opaque_handle(typ: &TypeDef, namespace: &str) -> String {
1622    let mut out = csharp_file_header();
1623    out.push_str("using System;\n\n");
1624
1625    out.push_str(&format!("namespace {};\n\n", namespace));
1626
1627    // Generate doc comment if available
1628    if !typ.doc.is_empty() {
1629        out.push_str("/// <summary>\n");
1630        for line in typ.doc.lines() {
1631            out.push_str(&format!("/// {}\n", line));
1632        }
1633        out.push_str("/// </summary>\n");
1634    }
1635
1636    let class_name = typ.name.to_pascal_case();
1637    out.push_str(&format!("public sealed class {} : IDisposable\n", class_name));
1638    out.push_str("{\n");
1639    out.push_str("    internal IntPtr Handle { get; }\n\n");
1640    out.push_str(&format!("    internal {}(IntPtr handle)\n", class_name));
1641    out.push_str("    {\n");
1642    out.push_str("        Handle = handle;\n");
1643    out.push_str("    }\n\n");
1644    out.push_str("    public void Dispose()\n");
1645    out.push_str("    {\n");
1646    out.push_str("        // Native free will be called by the runtime\n");
1647    out.push_str("    }\n");
1648    out.push_str("}\n");
1649
1650    out
1651}
1652
1653fn gen_record_type(
1654    typ: &TypeDef,
1655    namespace: &str,
1656    enum_names: &HashSet<String>,
1657    complex_enums: &HashSet<String>,
1658    custom_converter_enums: &HashSet<String>,
1659    _lang_rename_all: &str,
1660) -> String {
1661    let mut out = csharp_file_header();
1662    out.push_str("using System;\n");
1663    out.push_str("using System.Collections.Generic;\n");
1664    out.push_str("using System.Text.Json;\n");
1665    out.push_str("using System.Text.Json.Serialization;\n\n");
1666
1667    out.push_str(&format!("namespace {};\n\n", namespace));
1668
1669    // Generate doc comment if available
1670    if !typ.doc.is_empty() {
1671        out.push_str("/// <summary>\n");
1672        for line in typ.doc.lines() {
1673            out.push_str(&format!("/// {}\n", line));
1674        }
1675        out.push_str("/// </summary>\n");
1676    }
1677
1678    out.push_str(&format!("public sealed class {}\n", typ.name.to_pascal_case()));
1679    out.push_str("{\n");
1680
1681    for field in &typ.fields {
1682        // Skip unnamed tuple struct fields (e.g., _0, _1, 0, 1, etc.)
1683        if is_tuple_field(field) {
1684            continue;
1685        }
1686
1687        // Doc comment for field
1688        if !field.doc.is_empty() {
1689            out.push_str("    /// <summary>\n");
1690            for line in field.doc.lines() {
1691                out.push_str(&format!("    /// {}\n", line));
1692            }
1693            out.push_str("    /// </summary>\n");
1694        }
1695
1696        // If the field's type is an enum with a custom converter, emit a property-level
1697        // [JsonConverter] attribute. This ensures the custom converter takes precedence
1698        // over the global JsonStringEnumConverter registered in JsonSerializerOptions.
1699        let field_base_type = match &field.ty {
1700            TypeRef::Named(n) => Some(n.to_pascal_case()),
1701            TypeRef::Optional(inner) => match inner.as_ref() {
1702                TypeRef::Named(n) => Some(n.to_pascal_case()),
1703                _ => None,
1704            },
1705            _ => None,
1706        };
1707        if let Some(ref base) = field_base_type {
1708            if custom_converter_enums.contains(base) {
1709                out.push_str(&format!("    [JsonConverter(typeof({base}JsonConverter))]\n"));
1710            }
1711        }
1712
1713        // [JsonPropertyName("json_name")]
1714        // FFI-based languages serialize to JSON that Rust serde deserializes.
1715        // Since Rust uses default snake_case, JSON property names must be snake_case.
1716        let json_name = field.name.clone();
1717        out.push_str(&format!("    [JsonPropertyName(\"{}\")]\n", json_name));
1718
1719        let cs_name = to_csharp_name(&field.name);
1720
1721        // Check if field type is a complex enum (tagged enum with data variants).
1722        // These can't be simple C# enums — use JsonElement for flexible deserialization.
1723        let is_complex = matches!(&field.ty, TypeRef::Named(n) if complex_enums.contains(&n.to_pascal_case()));
1724
1725        if field.optional {
1726            // Optional fields: nullable type, no `required`, default = null
1727            let mapped = if is_complex {
1728                "JsonElement".to_string()
1729            } else {
1730                csharp_type(&field.ty).to_string()
1731            };
1732            let field_type = if mapped.ends_with('?') {
1733                mapped
1734            } else {
1735                format!("{mapped}?")
1736            };
1737            out.push_str(&format!("    public {} {} {{ get; set; }}", field_type, cs_name));
1738            out.push_str(" = null;\n");
1739        } else if typ.has_default || field.default.is_some() {
1740            // Field with an explicit default value or part of a type with defaults.
1741            // Use typed_default from IR to get Rust-compatible defaults.
1742            use alef_core::ir::DefaultValue;
1743
1744            // First pass: determine what the default value will be
1745            let base_type = if is_complex {
1746                "JsonElement".to_string()
1747            } else {
1748                csharp_type(&field.ty).to_string()
1749            };
1750
1751            // Duration fields are mapped to ulong? so that 0 is distinguishable from
1752            // "not set". Always default to null here; Rust has its own default.
1753            if matches!(&field.ty, TypeRef::Duration) {
1754                // base_type is already "ulong?" (from csharp_type); don't add another "?"
1755                let nullable_type = if base_type.ends_with('?') {
1756                    base_type.clone()
1757                } else {
1758                    format!("{}?", base_type)
1759                };
1760                out.push_str(&format!(
1761                    "    public {} {} {{ get; set; }} = null;\n",
1762                    nullable_type, cs_name
1763                ));
1764                out.push('\n');
1765                continue;
1766            }
1767
1768            let default_val = match &field.typed_default {
1769                Some(DefaultValue::BoolLiteral(b)) => b.to_string(),
1770                Some(DefaultValue::IntLiteral(n)) => n.to_string(),
1771                Some(DefaultValue::FloatLiteral(f)) => {
1772                    let s = f.to_string();
1773                    let s = if s.contains('.') { s } else { format!("{s}.0") };
1774                    match &field.ty {
1775                        TypeRef::Primitive(PrimitiveType::F32) => format!("{}f", s),
1776                        _ => s,
1777                    }
1778                }
1779                Some(DefaultValue::StringLiteral(s)) => {
1780                    let escaped = s
1781                        .replace('\\', "\\\\")
1782                        .replace('"', "\\\"")
1783                        .replace('\n', "\\n")
1784                        .replace('\r', "\\r")
1785                        .replace('\t', "\\t");
1786                    format!("\"{}\"", escaped)
1787                }
1788                Some(DefaultValue::EnumVariant(v)) => {
1789                    // When the C# field type is `string` (the referenced enum was excluded /
1790                    // collapsed to its serde JSON tag), emit the variant tag as a string literal
1791                    // rather than `string.VariantName` which would resolve to a missing static.
1792                    if base_type == "string" || base_type == "string?" {
1793                        format!("\"{}\"", v.to_pascal_case())
1794                    } else {
1795                        format!("{}.{}", base_type, v.to_pascal_case())
1796                    }
1797                }
1798                Some(DefaultValue::None) => "null".to_string(),
1799                Some(DefaultValue::Empty) | None => match &field.ty {
1800                    TypeRef::Vec(_) => "[]".to_string(),
1801                    TypeRef::Map(k, v) => format!("new Dictionary<{}, {}>()", csharp_type(k), csharp_type(v)),
1802                    TypeRef::String | TypeRef::Char | TypeRef::Path => "\"\"".to_string(),
1803                    TypeRef::Json => "null".to_string(),
1804                    TypeRef::Bytes => "Array.Empty<byte>()".to_string(),
1805                    TypeRef::Primitive(p) => match p {
1806                        PrimitiveType::Bool => "false".to_string(),
1807                        PrimitiveType::F32 => "0.0f".to_string(),
1808                        PrimitiveType::F64 => "0.0".to_string(),
1809                        _ => "0".to_string(),
1810                    },
1811                    TypeRef::Named(name) => {
1812                        let pascal = name.to_pascal_case();
1813                        if complex_enums.contains(&pascal) {
1814                            // Taggedunions (complex enums) should default to null
1815                            "null".to_string()
1816                        } else if enum_names.contains(&pascal) {
1817                            // Plain enums with serde(default) but no explicit variant default:
1818                            // Default to null
1819                            "null".to_string()
1820                        } else {
1821                            "default!".to_string()
1822                        }
1823                    }
1824                    _ => "default!".to_string(),
1825                },
1826            };
1827
1828            // Second pass: determine field type based on the default value
1829            let field_type = if (default_val == "null" && !base_type.ends_with('?')) || is_complex {
1830                format!("{}?", base_type)
1831            } else {
1832                base_type
1833            };
1834
1835            out.push_str(&format!(
1836                "    public {} {} {{ get; set; }} = {};\n",
1837                field_type, cs_name, default_val
1838            ));
1839        } else {
1840            // Non-optional field without explicit default.
1841            // Use type-appropriate zero values instead of `required` to avoid
1842            // JSON deserialization failures when fields are omitted via serde skip_serializing_if.
1843            let field_type = if is_complex {
1844                "JsonElement".to_string()
1845            } else {
1846                csharp_type(&field.ty).to_string()
1847            };
1848            // Duration is mapped to ulong? so null is the correct "not set" default.
1849            if matches!(&field.ty, TypeRef::Duration) {
1850                out.push_str(&format!(
1851                    "    public {} {} {{ get; set; }} = null;\n",
1852                    field_type, cs_name
1853                ));
1854            } else {
1855                let default_val = match &field.ty {
1856                    TypeRef::String | TypeRef::Char | TypeRef::Path | TypeRef::Json => "\"\"",
1857                    TypeRef::Vec(_) => "[]",
1858                    TypeRef::Bytes => "Array.Empty<byte>()",
1859                    TypeRef::Primitive(PrimitiveType::Bool) => "false",
1860                    TypeRef::Primitive(PrimitiveType::F32) => "0.0f",
1861                    TypeRef::Primitive(PrimitiveType::F64) => "0.0",
1862                    TypeRef::Primitive(_) => "0",
1863                    _ => "default!",
1864                };
1865                out.push_str(&format!(
1866                    "    public {} {} {{ get; set; }} = {};\n",
1867                    field_type, cs_name, default_val
1868                ));
1869            }
1870        }
1871
1872        out.push('\n');
1873    }
1874
1875    out.push_str("}\n");
1876
1877    out
1878}
1879
1880/// Apply a serde `rename_all` strategy to a variant name.
1881fn apply_rename_all(name: &str, rename_all: Option<&str>) -> String {
1882    match rename_all {
1883        Some("snake_case") => name.to_snake_case(),
1884        Some("camelCase") => name.to_lower_camel_case(),
1885        Some("PascalCase") => name.to_pascal_case(),
1886        Some("SCREAMING_SNAKE_CASE") => name.to_snake_case().to_uppercase(),
1887        Some("lowercase") => name.to_lowercase(),
1888        Some("UPPERCASE") => name.to_uppercase(),
1889        _ => name.to_lowercase(),
1890    }
1891}
1892
1893fn gen_enum(enum_def: &EnumDef, namespace: &str) -> String {
1894    let mut out = csharp_file_header();
1895    out.push_str("using System.Text.Json.Serialization;\n\n");
1896    let has_data_variants = enum_def.variants.iter().any(|v| !v.fields.is_empty());
1897
1898    // Tagged union: enum has a serde tag AND data variants → generate abstract record hierarchy
1899    if enum_def.serde_tag.is_some() && has_data_variants {
1900        return gen_tagged_union(enum_def, namespace);
1901    }
1902
1903    // If any variant has an explicit serde_rename whose value differs from what
1904    // SnakeCaseLower would produce (e.g. "og:image" vs "og_image"), the global
1905    // JsonStringEnumConverter(SnakeCaseLower) in KreuzcrawlLib.JsonOptions would
1906    // ignore [JsonPropertyName] and use the naming policy instead.
1907    // Also, the non-generic JsonStringEnumConverter does NOT support [JsonPropertyName]
1908    // on enum members at all. For these cases we generate a custom JsonConverter<T>
1909    // that explicitly maps each variant name.
1910    let needs_custom_converter = enum_def.variants.iter().any(|v| {
1911        if let Some(ref rename) = v.serde_rename {
1912            let snake = apply_rename_all(&v.name, enum_def.serde_rename_all.as_deref());
1913            rename != &snake
1914        } else {
1915            false
1916        }
1917    });
1918
1919    let enum_pascal = enum_def.name.to_pascal_case();
1920
1921    // Collect (json_name, pascal_name) pairs
1922    let variants: Vec<(String, String)> = enum_def
1923        .variants
1924        .iter()
1925        .map(|v| {
1926            let json_name = v
1927                .serde_rename
1928                .clone()
1929                .unwrap_or_else(|| apply_rename_all(&v.name, enum_def.serde_rename_all.as_deref()));
1930            let pascal_name = v.name.to_pascal_case();
1931            (json_name, pascal_name)
1932        })
1933        .collect();
1934
1935    out.push_str("using System;\n");
1936    out.push_str("using System.Text.Json;\n\n");
1937
1938    out.push_str(&format!("namespace {};\n\n", namespace));
1939
1940    // Generate doc comment if available
1941    if !enum_def.doc.is_empty() {
1942        out.push_str("/// <summary>\n");
1943        for line in enum_def.doc.lines() {
1944            out.push_str(&format!("/// {}\n", line));
1945        }
1946        out.push_str("/// </summary>\n");
1947    }
1948
1949    if needs_custom_converter {
1950        out.push_str(&format!("[JsonConverter(typeof({enum_pascal}JsonConverter))]\n"));
1951    }
1952    out.push_str(&format!("public enum {enum_pascal}\n"));
1953    out.push_str("{\n");
1954
1955    for (json_name, pascal_name) in &variants {
1956        // Find doc for this variant
1957        if let Some(v) = enum_def
1958            .variants
1959            .iter()
1960            .find(|v| v.name.to_pascal_case() == *pascal_name)
1961        {
1962            if !v.doc.is_empty() {
1963                out.push_str("    /// <summary>\n");
1964                for line in v.doc.lines() {
1965                    out.push_str(&format!("    /// {}\n", line));
1966                }
1967                out.push_str("    /// </summary>\n");
1968            }
1969        }
1970        out.push_str(&format!("    [JsonPropertyName(\"{json_name}\")]\n"));
1971        out.push_str(&format!("    {pascal_name},\n"));
1972    }
1973
1974    out.push_str("}\n");
1975
1976    // Generate custom converter class after the enum when needed
1977    if needs_custom_converter {
1978        out.push('\n');
1979        out.push_str(&format!(
1980            "/// <summary>Custom JSON converter for <see cref=\"{enum_pascal}\"/> that respects explicit variant names.</summary>\n"
1981        ));
1982        out.push_str(&format!(
1983            "internal sealed class {enum_pascal}JsonConverter : JsonConverter<{enum_pascal}>\n"
1984        ));
1985        out.push_str("{\n");
1986
1987        // Read
1988        out.push_str(&format!(
1989            "    public override {enum_pascal} Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)\n"
1990        ));
1991        out.push_str("    {\n");
1992        out.push_str("        var value = reader.GetString();\n");
1993        out.push_str("        return value switch\n");
1994        out.push_str("        {\n");
1995        for (json_name, pascal_name) in &variants {
1996            out.push_str(&format!(
1997                "            \"{json_name}\" => {enum_pascal}.{pascal_name},\n"
1998            ));
1999        }
2000        out.push_str(&format!(
2001            "            _ => throw new JsonException($\"Unknown {enum_pascal} value: {{value}}\")\n"
2002        ));
2003        out.push_str("        };\n");
2004        out.push_str("    }\n\n");
2005
2006        // Write
2007        out.push_str(&format!(
2008            "    public override void Write(Utf8JsonWriter writer, {enum_pascal} value, JsonSerializerOptions options)\n"
2009        ));
2010        out.push_str("    {\n");
2011        out.push_str("        var str = value switch\n");
2012        out.push_str("        {\n");
2013        for (json_name, pascal_name) in &variants {
2014            out.push_str(&format!(
2015                "            {enum_pascal}.{pascal_name} => \"{json_name}\",\n"
2016            ));
2017        }
2018        out.push_str(&format!(
2019            "            _ => throw new JsonException($\"Unknown {enum_pascal} value: {{value}}\")\n"
2020        ));
2021        out.push_str("        };\n");
2022        out.push_str("        writer.WriteStringValue(str);\n");
2023        out.push_str("    }\n");
2024        out.push_str("}\n");
2025    }
2026
2027    out
2028}
2029
2030/// Generate a C# abstract record hierarchy for internally tagged enums.
2031///
2032/// Maps `#[serde(tag = "type_field", rename_all = "snake_case")]` Rust enums to
2033/// a custom `JsonConverter<T>` that buffers all JSON properties before resolving
2034/// the discriminator. This is more robust than `[JsonPolymorphic]` which requires
2035/// the discriminator to be the first property in the JSON object.
2036fn gen_tagged_union(enum_def: &EnumDef, namespace: &str) -> String {
2037    let tag_field = enum_def.serde_tag.as_deref().unwrap_or("type");
2038    let enum_pascal = enum_def.name.to_pascal_case();
2039    let converter_name = format!("{enum_pascal}JsonConverter");
2040    // Namespace prefix used to fully-qualify inner types when their short name is shadowed
2041    // by a nested record of the same name (e.g. ContentPart.ImageUrl shadows ImageUrl).
2042    let ns = namespace;
2043
2044    let mut out = csharp_file_header();
2045    out.push_str("using System;\n");
2046    out.push_str("using System.Collections.Generic;\n");
2047    out.push_str("using System.Text.Json;\n");
2048    out.push_str("using System.Text.Json.Serialization;\n\n");
2049    out.push_str(&format!("namespace {};\n\n", namespace));
2050
2051    // Doc comment
2052    if !enum_def.doc.is_empty() {
2053        out.push_str("/// <summary>\n");
2054        for line in enum_def.doc.lines() {
2055            out.push_str(&format!("/// {}\n", line));
2056        }
2057        out.push_str("/// </summary>\n");
2058    }
2059
2060    // Use custom converter instead of [JsonPolymorphic] to handle discriminator in any position
2061    out.push_str(&format!("[JsonConverter(typeof({converter_name}))]\n"));
2062    out.push_str(&format!("public abstract record {enum_pascal}\n"));
2063    out.push_str("{\n");
2064
2065    // Collect all variant pascal names to check for field-name-to-variant-name clashes
2066    let variant_names: std::collections::HashSet<String> =
2067        enum_def.variants.iter().map(|v| v.name.to_pascal_case()).collect();
2068
2069    // Nested sealed records for each variant
2070    for variant in &enum_def.variants {
2071        let pascal = variant.name.to_pascal_case();
2072
2073        if !variant.doc.is_empty() {
2074            out.push_str("    /// <summary>\n");
2075            for line in variant.doc.lines() {
2076                out.push_str(&format!("    /// {}\n", line));
2077            }
2078            out.push_str("    /// </summary>\n");
2079        }
2080
2081        if variant.fields.is_empty() {
2082            // Unit variant → sealed record with no fields
2083            out.push_str(&format!("    public sealed record {pascal}() : {enum_pascal};\n\n"));
2084        } else {
2085            // CS8910: when a single-field variant has a parameter whose TYPE equals the record name
2086            // (e.g., record ImageUrl(ImageUrl Value)), the primary constructor conflicts with the
2087            // synthesized copy constructor. Use a property-based record body instead.
2088            // This applies to both tuple fields and named fields that get renamed to "Value".
2089            let is_copy_ctor_clash = variant.fields.len() == 1 && {
2090                let field_cs_type = csharp_type(&variant.fields[0].ty);
2091                field_cs_type.as_ref() == pascal
2092            };
2093
2094            if is_copy_ctor_clash {
2095                let cs_type = csharp_type(&variant.fields[0].ty);
2096                // Fully qualify the inner type to avoid the nested record shadowing the
2097                // standalone type of the same name (e.g. `ContentPart.ImageUrl` would shadow
2098                // `LiterLlm.ImageUrl` within the `ContentPart` abstract record body).
2099                let qualified_cs_type = format!("global::{ns}.{cs_type}");
2100                out.push_str(&format!("    public sealed record {pascal} : {enum_pascal}\n"));
2101                out.push_str("    {\n");
2102                out.push_str(&format!(
2103                    "        public required {qualified_cs_type} Value {{ get; init; }}\n"
2104                ));
2105                out.push_str("    }\n\n");
2106            } else {
2107                // Data variant → sealed record with fields as constructor params
2108                out.push_str(&format!("    public sealed record {pascal}(\n"));
2109                for (i, field) in variant.fields.iter().enumerate() {
2110                    let cs_type = csharp_type(&field.ty);
2111                    let cs_type = if field.optional && !cs_type.ends_with('?') {
2112                        format!("{cs_type}?")
2113                    } else {
2114                        cs_type.to_string()
2115                    };
2116                    let comma = if i < variant.fields.len() - 1 { "," } else { "" };
2117                    if is_tuple_field(field) {
2118                        out.push_str(&format!("        {cs_type} Value{comma}\n"));
2119                    } else {
2120                        let json_name = field.name.trim_start_matches('_');
2121                        let cs_name = to_csharp_name(json_name);
2122                        // Check if this field name clashes with:
2123                        // 1. The variant pascal name (e.g., "Slide" variant with "slide" field → "Slide" param)
2124                        // 2. The field type name (e.g., "ImageUrl" type with "url" field → "Url" param matching a nested record)
2125                        // 3. Another variant pascal name (e.g., nested "Title" record with "title" field in "Slide" variant)
2126                        let clashes = cs_name == pascal || cs_name == cs_type || variant_names.contains(&cs_name);
2127                        if clashes {
2128                            // Rename to Value with JSON property mapping to preserve the original field name
2129                            out.push_str(&format!(
2130                                "        [property: JsonPropertyName(\"{json_name}\")] {cs_type} Value{comma}\n"
2131                            ));
2132                        } else {
2133                            out.push_str(&format!(
2134                                "        [property: JsonPropertyName(\"{json_name}\")] {cs_type} {cs_name}{comma}\n"
2135                            ));
2136                        }
2137                    }
2138                }
2139                out.push_str(&format!("    ) : {enum_pascal};\n\n"));
2140            }
2141        }
2142    }
2143
2144    out.push_str("}\n\n");
2145
2146    // Generate custom converter that buffers the JSON document before dispatching
2147    out.push_str(&format!(
2148        "/// <summary>Custom JSON converter for <see cref=\"{enum_pascal}\"/> that reads the \"{tag_field}\" discriminator from any position.</summary>\n"
2149    ));
2150    out.push_str(&format!(
2151        "internal sealed class {converter_name} : JsonConverter<{enum_pascal}>\n"
2152    ));
2153    out.push_str("{\n");
2154
2155    // Read method
2156    out.push_str(&format!(
2157        "    public override {enum_pascal} Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)\n"
2158    ));
2159    out.push_str("    {\n");
2160    out.push_str("        using var doc = JsonDocument.ParseValue(ref reader);\n");
2161    out.push_str("        var root = doc.RootElement;\n");
2162    out.push_str(&format!(
2163        "        if (!root.TryGetProperty(\"{tag_field}\", out var tagEl))\n"
2164    ));
2165    out.push_str(&format!(
2166        "            throw new JsonException(\"{enum_pascal}: missing \\\"{tag_field}\\\" discriminator\");\n"
2167    ));
2168    out.push_str("        var tag = tagEl.GetString();\n");
2169    out.push_str("        var json = root.GetRawText();\n");
2170    out.push_str("        return tag switch\n");
2171    out.push_str("        {\n");
2172
2173    for variant in &enum_def.variants {
2174        let discriminator = variant
2175            .serde_rename
2176            .clone()
2177            .unwrap_or_else(|| apply_rename_all(&variant.name, enum_def.serde_rename_all.as_deref()));
2178        let pascal = variant.name.to_pascal_case();
2179        // Newtype/tuple variants have their inner type's fields inlined alongside the tag in JSON.
2180        // Deserialize the inner type from the full JSON object and wrap it in the record constructor.
2181        // Also treat single named-field variants whose parameter was renamed to "Value" (clash with
2182        // the variant name or the field's own type name) the same way.
2183        let is_tuple_newtype = variant.fields.len() == 1 && is_tuple_field(&variant.fields[0]);
2184        let is_named_clash_newtype = variant.fields.len() == 1 && !is_tuple_field(&variant.fields[0]) && {
2185            let f = &variant.fields[0];
2186            let cs_type = csharp_type(&f.ty);
2187            let cs_name = to_csharp_name(f.name.trim_start_matches('_'));
2188            cs_name == pascal || cs_name == cs_type
2189        };
2190        let is_newtype = is_tuple_newtype || is_named_clash_newtype;
2191        if is_newtype {
2192            let inner_cs_type = csharp_type(&variant.fields[0].ty);
2193            // CS8910: when inner type name equals variant name, use object initializer
2194            // (no primary constructor exists — property-based record was emitted)
2195            if inner_cs_type == pascal {
2196                out.push_str(&format!(
2197                    "            \"{discriminator}\" => new {enum_pascal}.{pascal} {{ Value = JsonSerializer.Deserialize<{inner_cs_type}>(json, options)!\n"
2198                ));
2199                out.push_str(&format!(
2200                    "                ?? throw new JsonException(\"Failed to deserialize {enum_pascal}.{pascal}.Value\") }},\n"
2201                ));
2202            } else {
2203                out.push_str(&format!(
2204                    "            \"{discriminator}\" => new {enum_pascal}.{pascal}(\n"
2205                ));
2206                out.push_str(&format!(
2207                    "                JsonSerializer.Deserialize<{inner_cs_type}>(json, options)!\n"
2208                ));
2209                out.push_str(&format!(
2210                    "                    ?? throw new JsonException(\"Failed to deserialize {enum_pascal}.{pascal}.Value\")),\n"
2211                ));
2212            }
2213        } else {
2214            out.push_str(&format!(
2215                "            \"{discriminator}\" => JsonSerializer.Deserialize<{enum_pascal}.{pascal}>(json, options)!\n"
2216            ));
2217            out.push_str(&format!(
2218                "                ?? throw new JsonException(\"Failed to deserialize {enum_pascal}.{pascal}\"),\n"
2219            ));
2220        }
2221    }
2222
2223    out.push_str(&format!(
2224        "            _ => throw new JsonException($\"Unknown {enum_pascal} discriminator: {{tag}}\")\n"
2225    ));
2226    out.push_str("        };\n");
2227    out.push_str("    }\n\n");
2228
2229    // Write method
2230    out.push_str(&format!(
2231        "    public override void Write(Utf8JsonWriter writer, {enum_pascal} value, JsonSerializerOptions options)\n"
2232    ));
2233    out.push_str("    {\n");
2234
2235    // Build options without this converter to avoid infinite recursion
2236    out.push_str("        // Serialize the concrete type, then inject the discriminator\n");
2237    out.push_str("        switch (value)\n");
2238    out.push_str("        {\n");
2239
2240    for variant in &enum_def.variants {
2241        let discriminator = variant
2242            .serde_rename
2243            .clone()
2244            .unwrap_or_else(|| apply_rename_all(&variant.name, enum_def.serde_rename_all.as_deref()));
2245        let pascal = variant.name.to_pascal_case();
2246        // Newtype/tuple variants: serialize the inner Value's fields inline alongside the tag.
2247        // Also applies to single named-field variants whose parameter was renamed to "Value" due
2248        // to a clash with the variant name or the field's own type name.
2249        let is_tuple_newtype = variant.fields.len() == 1 && is_tuple_field(&variant.fields[0]);
2250        let is_named_clash_newtype = variant.fields.len() == 1 && !is_tuple_field(&variant.fields[0]) && {
2251            let f = &variant.fields[0];
2252            let cs_type = csharp_type(&f.ty);
2253            let cs_name = to_csharp_name(f.name.trim_start_matches('_'));
2254            cs_name == pascal || cs_name == cs_type
2255        };
2256        let is_newtype = is_tuple_newtype || is_named_clash_newtype;
2257        // dotnet format expects switch-case block braces indented one level
2258        // deeper than the `case` keyword (the body's indent), not aligned to
2259        // it — otherwise it reformats every commit and breaks alef-verify.
2260        out.push_str(&format!("            case {enum_pascal}.{pascal} v:\n"));
2261        out.push_str("                {\n");
2262        if is_newtype {
2263            out.push_str("                    var doc = JsonSerializer.SerializeToDocument(v.Value, options);\n");
2264        } else {
2265            out.push_str("                    var doc = JsonSerializer.SerializeToDocument(v, options);\n");
2266        }
2267        out.push_str("                    writer.WriteStartObject();\n");
2268        out.push_str(&format!(
2269            "                    writer.WriteString(\"{tag_field}\", \"{discriminator}\");\n"
2270        ));
2271        out.push_str("                    foreach (var prop in doc.RootElement.EnumerateObject())\n");
2272        out.push_str(&format!(
2273            "                        if (prop.Name != \"{tag_field}\") prop.WriteTo(writer);\n"
2274        ));
2275        out.push_str("                    writer.WriteEndObject();\n");
2276        out.push_str("                    break;\n");
2277        out.push_str("                }\n");
2278    }
2279
2280    out.push_str(&format!(
2281        "            default: throw new JsonException($\"Unknown {enum_pascal} subtype: {{value.GetType().Name}}\");\n"
2282    ));
2283    out.push_str("        }\n");
2284    out.push_str("    }\n");
2285    out.push_str("}\n");
2286
2287    out
2288}
2289
2290/// Generate Directory.Build.props with Nullable=enable and LangVersion=latest.
2291/// This is auto-generated (overwritten on each build) so it doesn't require user maintenance.
2292fn gen_directory_build_props() -> String {
2293    "<!-- auto-generated by alef (generate_bindings) -->\n\
2294<Project>\n  \
2295<PropertyGroup>\n    \
2296<Nullable>enable</Nullable>\n    \
2297<LangVersion>latest</LangVersion>\n  \
2298</PropertyGroup>\n\
2299</Project>\n"
2300        .to_string()
2301}
2302
2303/// Delete stale visitor-related files when visitor_callbacks is disabled.
2304/// When visitor_callbacks transitions from true → false, these files remain on disk
2305/// and cause CS8632 warnings (nullable context not enabled in these files).
2306fn delete_stale_visitor_files(base_path: &std::path::Path) -> anyhow::Result<()> {
2307    let stale_files = vec!["IVisitor.cs", "VisitorCallbacks.cs", "NodeContext.cs", "VisitResult.cs"];
2308
2309    for filename in stale_files {
2310        let path = base_path.join(filename);
2311        if path.exists() {
2312            std::fs::remove_file(&path)
2313                .map_err(|e| anyhow::anyhow!("Failed to delete stale visitor file {}: {}", path.display(), e))?;
2314        }
2315    }
2316
2317    Ok(())
2318}