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